前一篇處理輸入:終端機把按鍵編碼成 bytes,程式從 stdin 讀取。這一篇處理輸出:程式把 bytes 寫進 stdout,終端機再把它們顯示成畫面。
像 cat 或 ls 這類 CLI 工具把 stdout 當作連續的字元串流(stream)寫入:只要輸出包含換行符號(\n),終端機就會將游標移到下一行;當內容到達視窗底部時,終端機便自動向上捲動舊內容。全螢幕編輯器則不同,它需要掌控整個視窗,在任意行列位置繪製文字、定位游標,並在每次按鍵輸入後重新繪製畫面。
終端機與編輯器其實是兩個獨立的程式。編輯器不能直接呼叫終端機內部的「清空畫面」函式,只能把 bytes 寫進 stdout。終端機收到一般文字,例如 hello,就把文字畫出來;收到以 ESC 開頭的特定序列,就把它解讀成控制畫面的命令。例如 \x1b[2J 由四個 bytes 組成,代表清空整個畫面。
常用的幾個(\x1b 就是 ESC,27):
| 序列 | 動作 |
|---|---|
\x1b[2J |
清空整個畫面 |
\x1b[H |
游標移到左上角 |
\x1b[{y};{x}H |
游標移到第 y 行第 x 欄(1-based,不是 0-based) |
\x1b[K |
清除游標到行尾 |
\x1b[?25l / \x1b[?25h |
隱藏 / 顯示游標 |
\x1b[7m / \x1b[m |
反白 / 還原樣式 |
\x1b[?1049h / \x1b[?1049l |
切到 / 切回 alternate screen(下面馬上講) |
例如以下程式表示先清空畫面並把游標移到左上角,再反白顯示 hello:
fmt.Print("\x1b[2J\x1b[H") // 清畫面、游標回左上
fmt.Print("\x1b[7mhello\x1b[m") // 反白印出 hello
為了不覆蓋使用者原本在 Shell 的命令與輸出紀錄,我們需要先將終端機切換到全螢幕模式。
終端機設有兩個畫面緩衝區:Shell 平常使用的 main screen,負責保存命令輸出與捲動歷史;alternate screen 則是另一個獨立的全螢幕畫面,程式可以在裡面反覆重畫內容。進入 alternate screen 時,終端機顯示新的空白畫面;離開時切回 main screen,原本的 Shell 畫面與游標位置便會完整恢復。
os.Stdout.WriteString("\x1b[?1049h") // 進場:切到 alternate screen
defer os.Stdout.WriteString("\x1b[?1049l") // 退場:切回主畫面
\x1b[?1049h 切到 alternate screen,\x1b[?1049l 切回 main screen。用 defer 安排切回動作,程式正常離開時就不會把使用者留在 alternate screen。
切換到全螢幕畫面後,編輯器必須知道視窗的寬度與高度,才能決定畫面上要印出幾列文字,以及把狀態列固定在最底部。我們呼叫 term.GetSize 取得終端機視窗的欄數 w 與列數 h;若查詢失敗(例如在不支援的環境下執行),則預設備用尺寸 80 × 24,讓畫面仍可繼續繪製:
w, h, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
w, h = 80, 24
}
畫面繪製與游標移動都需要知道視窗尺寸以及游標在畫面上的位置,因此我們將這些狀態集中管理在 editor 中:
type editor struct {
screenRows int // 內容區列數,不含狀態列
screenCols int // 終端機欄數
cx, cy int // 游標位置,0-based
}
screenCols 保存終端機的總欄數。cx、cy 保存游標位於第幾欄與第幾列,兩者都從 0 開始計算。
screenRows 保存的是內容區列數,不是終端機的總列數。最下面一列要畫狀態列,所以 updateSize 把 h - 1 存進 screenRows:
e.screenCols = w
e.screenRows = h - 1
到這裡,程式啟動後已能切換到 alternate screen,並在 editor 保存終端機尺寸與游標位置。
準備好全螢幕環境與視窗尺寸後,接著我們要處理使用者的按鍵輸入,並即時將更新後的結果顯示在畫面上。
前一篇實現的 readKey 會等待並讀取一個按鍵,refreshScreen 則會按照 editor 目前的狀態畫出完整畫面。把這些動作放進主迴圈,程式就能持續回應使用者:先畫出目前狀態,再等待下一個按鍵,收到按鍵後更新狀態,接著回到迴圈開頭重畫。
因此,程式啟動時會先顯示第一個畫面。每當使用者按下按鍵,processKeypress 就更新 editor 的狀態,下一輪的 refreshScreen 再把更新結果顯示出來:
for {
e.refreshScreen() // 依目前狀態重畫
key := readKey() // 等待一個按鍵
if e.processKeypress(key) {
return
}
}
processKeypress 負責分派按鍵行為:收到 Ctrl+Q 時回傳 true 通知主迴圈離開;收到方向鍵時則呼叫 moveCursor 更新游標座標:
func (e *editor) processKeypress(key Key) bool {
switch key {
case ctrl('q'):
return true
case KeyArrowUp, KeyArrowDown, KeyArrowLeft, KeyArrowRight:
e.moveCursor(key)
}
return false
}
方向鍵只會透過 moveCursor 修改記憶體中的 cx 與 cy;當主迴圈進入下一次迭代呼叫 refreshScreen 時,才會把游標繪製到新座標。按下 Ctrl+Q 則會讓 processKeypress 回傳 true 結束主迴圈,並觸發 defer 登記的退場還原動作。
在主迴圈中,每次按鍵都會觸發一次重畫。如果直覺地先清空整個畫面再逐行寫入 ~,會引發畫面閃爍的問題。例如以下的寫法:
fmt.Print("\x1b[2J\x1b[H") // 清空畫面、游標回左上
for y := 0; y < e.screenRows; y++ {
fmt.Print("~\r\n")
}
這段程式把「清空畫面」與「逐行重畫」拆成多次寫入。第一個 fmt.Print 寫出 \x1b[2J 後,終端機會立刻清空畫面;接下來的迴圈才把 ~ 一行一行寫回去。終端機不知道程式還沒畫完,可能在任意一次 fmt.Print 後顯示目前收到的內容,使用者就會看到全空白或只畫到一半的畫面。每次按鍵都重複一次這個過程,畫面便會持續閃爍。
避免閃爍需要做三件事:
bytes.Buffer 裡,最後一次 write 送出。 終端機收到的是一個完整的更新,沒有中間狀態可以被看到。\x1b[2J 整面清空,改成每行畫完用 \x1b[K 清那一行的尾巴。 舊內容被新內容直接覆蓋,不存在「全空白」的時刻。\x1b[?25l 隱藏游標,完成後再用 \x1b[?25h 顯示。 游標不會跟著輸出過程在畫面上跳動。執行以下命令後,程式會先清空畫面,再逐行寫入文字。按住任意鍵連續觸發重畫,可以看到畫面閃爍:
go run ./render-screen/flicker
加上 -buffered 後,程式會在 buffer 裡組好整個畫面再一次寫入,並以逐行覆蓋取代整面清空。同樣按住任意鍵,畫面不會閃爍:
go run ./render-screen/flicker -buffered
refreshScreen 用 bytes.Buffer 解決畫面閃爍。先把整個畫面的繪製命令寫進 buffer,等內容全部組好後,再呼叫一次 os.Stdout.Write 送給終端機。重畫順序在 buffer 中分成三步:
\x1b[?25l 隱藏游標,並用 \x1b[H 將游標移到左上角準備開始繪製。drawRows 繪製內容(每行結尾用 \x1b[K 清除舊行尾),接著呼叫 drawStatusBar 印出狀態列。\x1b[%d;%dH 將游標移回使用者目前的編輯座標(注意轉為 1-based),並寫入 \x1b[?25h 恢復顯示游標。func (e *editor) refreshScreen() {
var b bytes.Buffer
b.WriteString("\x1b[?25l") // 重畫期間藏游標
b.WriteString("\x1b[H") // 游標回左上,從頭開始畫
e.drawRows(&b)
e.drawStatusBar(&b)
fmt.Fprintf(&b, "\x1b[%d;%dH", e.cy+1, e.cx+1) // 游標放到 (cx, cy),注意 1-based
b.WriteString("\x1b[?25h")
os.Stdout.Write(b.Bytes())
}
func (e *editor) drawRows(b *bytes.Buffer) {
for y := 0; y < e.screenRows; y++ {
b.WriteString("~")
b.WriteString("\x1b[K\r\n") // 清到行尾,取代整面清空
}
}
\x1b[H 出現在開頭是為了畫圖:接下來的內容從左上角開始逐行覆蓋舊畫面。結尾的定位則是給使用者看的:畫圖過程中游標一路跟著輸出跑到了畫面最底,不放回 (cx, cy) 它就停在狀態列上。
drawStatusBar 用開頭表格裡的反白:寫 \x1b[7m、印狀態文字、用空白補滿整行寬度、\x1b[m 還原樣式。
當使用者拖拉改變終端機視窗大小時,作業系統會向程式發送 SIGWINCH 訊號。編輯器收到訊號後需要重新取得視窗尺寸,並根據新尺寸重新繪製畫面。
接收 SIGWINCH 訊號需要使用 Channel。我們在背景啟動一個 Goroutine 監聽訊號 Channel,當收到視窗改變通知時設定 Atomic Flag;主迴圈重畫時便能安全地檢查該 Flag,避免兩個 Goroutine 同時存取 editor 狀態與寫入終端機所造成的資料競爭(Data Race)。
var resized atomic.Bool
func watchResize() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGWINCH)
go func() {
for range ch {
resized.Store(true)
}
}()
}
在 refreshScreen 執行繪製前,程式會先檢查並重置 resized Flag;若偵測到視窗尺寸變更,便呼叫 updateSize 重新計算規格:
if resized.Swap(false) {
e.updateSize()
}
接著執行:
go run ./render-screen
程式啟動後會切換到全螢幕畫面,內容區每行開頭均會印出 ~,最下方則顯示狀態列。此時可以使用方向鍵移動游標,或按下 Ctrl+Q 離開程式並復原原本的終端機畫面;若在執行期間拖拉調整視窗大小,按下任意按鍵後便會以新尺寸重新繪製。
目前編輯器已能接管終端機視窗並流暢重畫,但畫面上只有空殼 ~,且 cx 與 cy 僅代表螢幕視窗內的座標。下一篇將載入真實檔案內容,並透過 viewport 處理檔案行數大於視窗尺寸時的捲動與顯示。